<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>手机简谱编曲器</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;background:#f5f5f5;-webkit-tap-highlight-color:transparent}
#wrap{max-width:1200px;margin:0 auto;background:#fff;padding:8px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.15)}
h2{margin:8px 0;font-size:18px;text-align:center}

/* 控制区：纵向大按钮 */
.ctrl{display:flex;flex-wrap:wrap;gap:2px;margin-bottom:2px}
.ctrl button{height:48px;font-size:16px;border:0;border-radius:2px;color:#fff;cursor:pointer}
#play{background:#4CAF50}
#stop{background:#f44336}
#ins{background:#607D8B}
#del{background:#f44336}
#export{background:#2196F3}
#import{background:#FF9800}
#exportCode{background:#009800}

.row{display:flex;align-items:center;gap:6px;margin:6px 0;font-size:14px}
input[type=range]{flex:1;height:32px}
select{height:32px;border:1px solid #ccc;border-radius:4px}

/* 序列区：大字号 + 紧凑间隙 */
#seq{background:#fafafa;border-radius:8px;padding:6px;font-size:0;max-height:40vh;overflow-y:auto}
.item{display:inline-flex;align-items:center;margin:1px;padding:4px 6px;border-radius:6px;font-size:16px;position:relative}
.item .num{font-size:20px;font-weight:bold}
.item .dur{font-size:12px;margin-left:4px;opacity:.8}
.item::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;border:2px solid transparent;transition:all .2s}
.cur::before{border:2px solid #FF9800}

/* 四色 + 休止灰 */
.c3{background:#2196F3;color:#fff}
.c4{background:#4CAF50;color:#fff}
.c5{background:#FFEB3B;color:#000}
.c6{background:#F44336;color:#fff}
.rest{background:#9E9E9E;color:#fff}

/* 音高/时值按钮区：自动换行大按钮 */
#pads{display:flex;flex-wrap:wrap;gap:2px}
#pads button{min-width:47px;height:47px;font-size:18px;border:1;border-radius:5px;cursor:pointer}
#st{text-align:center;margin:6px;font-size:14px;color:#555}

.item{user-select:none;cursor:pointer}   /* 整手可点，文字不被选中 */
.item *{pointer-events:none}  
</style>
</head>
<body>
<div id="wrap">
  <div style="display:flex;align-items:center;justify-content:space-between;margin:8px 0">
  <h2 style="margin:0;font-size:18px">🎵 简谱编曲器</h2>
  <input id="fileName" type="text" value="文件名" style="height:28px;padding:0 6px;border:1px solid #ccc;border-radius:4px;font-size:14px;width:100px">
</div>

  <!-- 控制区 -->
  <div class="ctrl">
    <button id="play">▶️ 播放</button>
    <button id="stop">⏹️ 停止</button>
    <button id="ins">➕ 插入</button>
    <button id="del">🗑️ 删除</button>
	      <button id="export">📤 导出</button>
      <button id="import">📥 导入</button>
	  <button id="exportCode">📄 导出代码</button>
    <div class="row">
      <label>速度<input type="range" id="spd" min="0.25" max="2" step="0.05" value="1"></label>
      <span id="spdVal">1.00</span>

      <label>音色<select id="wave">
        <option value="sine">正弦波</option>
        <option value="square" selected>方波</option>
        <option value="sawtooth">锯齿波</option>
        <option value="triangle">三角波</option>
      </select></label>
    </div>

  </div>

  <!-- 音高 + 时值 -->
  <div id="pads"></div>
  <!-- 序列 -->
  <div id="seq"></div>
  <!-- 状态 -->
  <div id="st">就绪</div>
</div>

<script>
(()=>{
  const seq=[['C5',1]], $=s=>document.querySelector(s);
  const F={C3:130.81,D3:146.83,E3:164.81,F3:174.61,G3:196.00,A3:220.00,B3:246.94,
           C4:261.63,D4:293.66,E4:329.63,F4:349.23,G4:392.00,A4:440.00,B4:493.88,
           C5:523.25,D5:587.33,E5:659.25,F5:698.46,G5:783.99,A5:880.00,B5:987.77,
           C6:1046.50,D6:1174.66,E6:1318.51,F6:1396.91,G6:1567.98,A6:1760.00,B6:1975.53};

  let ctx, idx=0, play=0, spdVal=1, selectedWaveform='square';

  /* 简谱映射表 C3-C6 + 休止符 */
  const pit=[
    {n:'C3',s:'1',c:'c3'},{n:'D3',s:'2',c:'c3'},{n:'E3',s:'3',c:'c3'},{n:'F3',s:'4',c:'c3'},{n:'G3',s:'5',c:'c3'},{n:'A3',s:'6',c:'c3'},{n:'B3',s:'7',c:'c3'},
    {n:'C4',s:'1',c:'c4'},{n:'D4',s:'2',c:'c4'},{n:'E4',s:'3',c:'c4'},{n:'F4',s:'4',c:'c4'},{n:'G4',s:'5',c:'c4'},{n:'A4',s:'6',c:'c4'},{n:'B4',s:'7',c:'c4'},
    {n:'C5',s:'1',c:'c5'},{n:'D5',s:'2',c:'c5'},{n:'E5',s:'3',c:'c5'},{n:'F5',s:'4',c:'c5'},{n:'G5',s:'5',c:'c5'},{n:'A5',s:'6',c:'c5'},{n:'B5',s:'7',c:'c5'},
    {n:'C6',s:'1',c:'c6'},{n:'D6',s:'2',c:'c6'},{n:'E6',s:'3',c:'c6'},{n:'F6',s:'4',c:'c6'},{n:'G6',s:'5',c:'c6'},{n:'A6',s:'6',c:'c6'}
  ];

  /* 时长映射：显示 → 传值 */
  const durMap=[
    {show:'1/6',  val:0.166},{show:'1/8',  val:0.125},{show:'1/4',  val:0.25},
    {show:'1/3',  val:0.333},{show:'1/2',  val:0.5},{show:'3/4',  val:0.75},
    {show:'1',   val:1},{show:'5/4', val:1.25},{show:'3/2', val:1.5},
    {show:'2',   val:2},{show:'3',   val:3},{show:'4',   val:4}
  ];

  /* 渲染：简谱数字 + 下标时长 + 四色 */
  function render(){
    $('#seq').innerHTML=seq.map((n,i)=>{
      if(n[0]==='rest') return `<div class="item rest ${i===idx?'cur':''}" data-i="${i}">
                                  <span class="num">0</span>
                                  <span class="dur">${durMap.find(d=>d.val===n[1])?.show||n[1]}</span>
                                </div>`;
      const p=pit.find(x=>x.n===n[0]);
      return `<div class="item ${p?.c||''} ${i===idx?'cur':''}" data-i="${i}">
                <span class="num">${p?.s||n[0]}</span>
                <span class="dur">${durMap.find(d=>d.val===n[1])?.show||n[1]}</span>
              </div>`;
    }).join('');
  }

  /* 播放单个音符 */
  async function playOne(i){
    if(!ctx){ctx=new(AudioContext||webkitAudioContext)(); await ctx.resume();}
    const [no,d]=seq[i], dur=d*0.25/spdVal;
    if(no!=='rest'){
      const o=ctx.createOscillator(), g=ctx.createGain();
      o.connect(g); g.connect(ctx.destination);
      o.frequency.value=F[no]||440;
      o.type=selectedWaveform;
      const t=ctx.currentTime;
      g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(0.3,t+0.02);
      g.gain.linearRampToValueAtTime(0,t+dur);
      o.start(t); o.stop(t+dur);
    }
    await new Promise(r=>setTimeout(r,dur*1000));
  }

  /* 播放：从当前选中音符开始 */
  async function start(){
    if(play)return;
    play=1; $('#play').disabled=1;
    for(; idx<seq.length&&play; idx++){ render(); await playOne(idx); }
    play=0; $('#play').disabled=0; $('#st').textContent='播放完毕';
  }
  function stop(){play=0; $('#play').disabled=0; $('#st').textContent='已停止';}

  /* 事件委托（手机大按钮友好） */
  /* ===== 事件委托（只留一份，逻辑干净） ===== */
$('#wrap').addEventListener('click', e => {
  const t = e.target;                 // 实际被点到的元素
  const item = t.closest('.item');    // 如果点在音符按钮上，这里能拿到

  /* 1. 音符按钮（整块可点） */
  if (item && !item.classList.contains('close')) {
    stop();
    idx = +item.dataset.i;
    render();
    return;
  }

  /* 2. 控制按钮 */
  if (t.id === 'play') { start(); return; }
  if (t.id === 'stop') { stop(); return; }
  if (t.id === 'ins') { seq.splice(idx, 0, ['rest', 1]); render(); return; }
  if (t.id === 'del') {
    if (seq.length <= 1) { $('#st').textContent = '至少保留一个音符'; return; }
    seq.splice(idx, 1);
    if (idx >= seq.length) idx = seq.length - 1;
    render();
    $('#st').textContent = '已删除';
    return;
  }

  /* 3. 音高/时值面板按钮 */
  if (t.dataset.n) {          // 音高
    seq[idx][0] = t.dataset.n;
    playOne(idx);
    if (idx === seq.length - 1) { seq.push(['rest', 1]); idx++; }
    else idx++;
    render();
    return;
  }
  if (t.dataset.d) {          // 时值
    seq[idx][1] = +t.dataset.d;
    playOne(idx);
    if (idx === seq.length - 1) { seq.push(['rest', 1]); idx++; }
    else idx++;
    render();
    return;
  }
});

  /* 速度/音色/导出/导入 */
  $('#spd').oninput=e=>{spdVal=+e.target.value;$('#spdVal').textContent=spdVal.toFixed(2);};
  $('#wave').onchange=e=>selectedWaveform=e.target.value;
  /* 导出按钮事件里，把原来的固定文件名替换为文本框内容 */
$('#export').onclick = () => {
  if (!seq.length) { $('#st').textContent = '空序列'; return; }
  const name = $('#fileName').value.trim() || '文件名';          // ★ 取文本框值
  const blob = new Blob([JSON.stringify({ seq, spd: spdVal, wave: selectedWaveform }, null, 2)],
                        { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = name + '.json';                                   // ★ 动态文件名
  a.click();
  $('#st').textContent = '已导出';
};
  $('#import').onclick=()=>{const inp=document.createElement('input');inp.type='file';inp.accept='.json';inp.onchange=e=>{const file=e.target.files[0];if(!file)return;const reader=new FileReader();reader.onload=ev=>{try{const data=JSON.parse(ev.target.result);if(!Array.isArray(data.seq))throw'格式错误';seq.length=0;seq.push(...data.seq);idx=0;spdVal=data.spd||1;selectedWaveform=data.wave||'sine';$('#spd').value=spdVal;$('#spdVal').textContent=spdVal.toFixed(2);$('#wave').value=selectedWaveform;render();$('#st').textContent='已导入';}catch(err){$('#st').textContent='导入失败：'+err;}};reader.readAsText(file);};inp.click();};
  /* 生成代码 */
  $('#exportCode').onclick = () => {
  if (!seq.length) { $('#st').textContent = '空序列'; return; }

  const name = ($('#fileName').value.trim() || 'music') + '_player.html';

  const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自动播放简谱</title>
<style>
body{background:#111;color:#eee;font-family:sans-serif;text-align:center;padding-top:20%}
button{font-size:20px;padding:10px 20px}
</style>
</head>
<body>
<h1>🎵 正在播放简谱...</h1>
<button onclick="togglePlay()">暂停/继续</button>
<script>
const seq = ${JSON.stringify(seq)};
const spd = ${spdVal};
const waveform = "${selectedWaveform}";
const F = ${JSON.stringify(F)};
let ctx, playing = false, inited = false, idx = 0;

async function playOne(i) {
  if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); await ctx.resume(); }
  const [no, d] = seq[i], dur = d * 0.25 / spd;
  if (no !== 'rest') {
    const o = ctx.createOscillator(), g = ctx.createGain();
    o.connect(g); g.connect(ctx.destination);
    o.frequency.value = F[no] || 440;
    o.type = waveform;
    const t = ctx.currentTime;
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(0.3, t + 0.02);
    g.gain.linearRampToValueAtTime(0, t + dur);
    o.start(t); o.stop(t + dur);
  }
  await new Promise(r => setTimeout(r, dur * 1000));
}

async function loop() {
  while (playing && idx < seq.length) {
    await playOne(idx++);
  }
  if (idx >= seq.length) idx = 0;
  if (playing) loop();
}

function togglePlay() {
  playing = !playing;
  if (playing) loop();
}

window.onload = () => {
  ctx = new (window.AudioContext || window.webkitAudioContext)();

};
<\/script>
</body>
</html>`;

  const blob = new Blob([html], { type: 'text/html' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = name;
  a.click();
  $('#st').textContent = '已导出代码';
};
  
  /* 生成代码结束 */
  /* 生成面板 */
  $('#pads').innerHTML='<div>'+pit.map(p=>`<button class="pad ${p.c}" data-n="${p.n}">${p.s}</button>`).join('')+`<button class="pad rest" data-n="rest">0</button>`+durMap.map(d=>`<button class="pad" data-d="${d.val}">${d.show}</button>`).join('')+'</div>';
  
  
  render();
})();
</script>
</body>
</html>